const fs = require("fs");
const JSZip = require("jszip");
const { PassThrough } = require("readable-stream");
const ZipStream = require("../utils/zip-stream");
const StreamBuf = require("../utils/stream-buf");

const utils = require("../utils/utils");
const XmlStream = require("../utils/xml-stream");
const { bufferToString } = require("../utils/browser-buffer-decode");

const StylesXform = require("./xform/style/styles-xform");

const CoreXform = require("./xform/core/core-xform");
const SharedStringsXform = require("./xform/strings/shared-strings-xform");
const RelationshipsXform = require("./xform/core/relationships-xform");
const ContentTypesXform = require("./xform/core/content-types-xform");
const AppXform = require("./xform/core/app-xform");
const WorkbookXform = require("./xform/book/workbook-xform");
const WorksheetXform = require("./xform/sheet/worksheet-xform");
const DrawingXform = require("./xform/drawing/drawing-xform");
const TableXform = require("./xform/table/table-xform");
const CommentsXform = require("./xform/comment/comments-xform");
const VmlNotesXform = require("./xform/comment/vml-notes-xform");

const theme1Xml = require("./xml/theme1.js");
const WorkbookTemplate = require('.././template/workbook-template');
const Enums = require('../doc/enums');
const ZipAdapter = require('../template/zip-adapter');
const _ = require('lodash');
const CellTemplate = require('../template/cell-template');

class XLSX {
  constructor(workbook) {
    this.workbook = workbook;
  }

  // ===============================================================================
  // Workbook
  // =========================================================================
  // Read

  async readFile(filename, options) {
    if (!(await utils.fs.exists(filename))) {
      throw new Error(`File not found: ${filename}`);
    }
    const stream = fs.createReadStream(filename);
    try {
      const workbook = await this.read(stream, options);
      stream.close();
      return workbook;
    } catch (error) {
      stream.close();
      throw error;
    }
  }

  parseRels(stream) {
    const xform = new RelationshipsXform();
    return xform.parseStream(stream);
  }

  parseWorkbook(stream) {
    const xform = new WorkbookXform();
    return xform.parseStream(stream);
  }

  parseSharedStrings(stream) {
    const xform = new SharedStringsXform();
    return xform.parseStream(stream);
  }

  reconcile(model, options) {
    const workbookXform = new WorkbookXform();
    const worksheetXform = new WorksheetXform(options);
    const drawingXform = new DrawingXform();
    const tableXform = new TableXform();

    workbookXform.reconcile(model);

    // reconcile drawings with their rels
    const drawingOptions = {
      media: model.media,
      mediaIndex: model.mediaIndex,
    };
    Object.keys(model.drawings).forEach((name) => {
      const drawing = model.drawings[name];
      const drawingRel = model.drawingRels[name];
      if (drawingRel) {
        drawingOptions.rels = drawingRel.reduce((o, rel) => {
          o[rel.Id] = rel;
          return o;
        }, {});
        (drawing.anchors || []).forEach((anchor) => {
          const hyperlinks = anchor.picture && anchor.picture.hyperlinks;
          if (hyperlinks && drawingOptions.rels[hyperlinks.rId]) {
            hyperlinks.hyperlink = drawingOptions.rels[hyperlinks.rId].Target;
            delete hyperlinks.rId;
          }
        });
        drawingXform.reconcile(drawing, drawingOptions);
      }
    });

    // reconcile tables with the default styles
    const tableOptions = {
      styles: model.styles,
    };
    Object.values(model.tables).forEach((table) => {
      tableXform.reconcile(table, tableOptions);
    });

    const sheetOptions = {
      styles: model.styles,
      sharedStrings: model.sharedStrings,
      media: model.media,
      mediaIndex: model.mediaIndex,
      date1904: model.properties && model.properties.date1904,
      drawings: model.drawings,
      comments: model.comments,
      tables: model.tables,
      vmlDrawings: model.vmlDrawings,
    };
    model.worksheets.forEach((worksheet) => {
      worksheet.relationships = model.worksheetRels[worksheet.sheetNo];
      worksheetXform.reconcile(worksheet, sheetOptions);
    });

    // delete unnecessary parts
    delete model.worksheetHash;
    delete model.worksheetRels;
    delete model.globalRels;
    delete model.sharedStrings;
    delete model.workbookRels;
    delete model.sheetDefs;
    delete model.styles;
    delete model.mediaIndex;
    delete model.drawings;
    delete model.drawingRels;
    delete model.vmlDrawings;
  }

  async _processWorksheetEntry(stream, model, sheetNo, options, path) {
    const xform = new WorksheetXform(options);
    const worksheet = await xform.parseStream(stream);
    worksheet.sheetNo = sheetNo;
    model.worksheetHash[path] = worksheet;
    model.worksheets.push(worksheet);
  }

  async _processCommentEntry(stream, model, name) {
    const xform = new CommentsXform();
    const comments = await xform.parseStream(stream);
    model.comments[`../${name}.xml`] = comments;
  }

  async _processTableEntry(stream, model, name) {
    const xform = new TableXform();
    const table = await xform.parseStream(stream);
    model.tables[`../tables/${name}.xml`] = table;
  }

  async _processWorksheetRelsEntry(stream, model, sheetNo) {
    const xform = new RelationshipsXform();
    const relationships = await xform.parseStream(stream);
    model.worksheetRels[sheetNo] = relationships;
  }

  async _processMediaEntry(entry, model, filename) {
    const lastDot = filename.lastIndexOf(".");
    // if we can't determine extension, ignore it
    if (lastDot >= 1) {
      const extension = filename.substr(lastDot + 1);
      const name = filename.substr(0, lastDot);
      await new Promise((resolve, reject) => {
        const streamBuf = new StreamBuf();
        streamBuf.on("finish", () => {
          model.mediaIndex[filename] = model.media.length;
          model.mediaIndex[name] = model.media.length;
          const medium = {
            type: "image",
            name,
            extension,
            buffer: streamBuf.toBuffer(),
          };
          model.media.push(medium);
          resolve();
        });
        entry.on("error", (error) => {
          reject(error);
        });
        entry.pipe(streamBuf);
      });
    }
  }

  async _processDrawingEntry(entry, model, name) {
    const xform = new DrawingXform();
    const drawing = await xform.parseStream(entry);
    model.drawings[name] = drawing;
  }

  async _processDrawingRelsEntry(entry, model, name) {
    const xform = new RelationshipsXform();
    const relationships = await xform.parseStream(entry);
    model.drawingRels[name] = relationships;
  }

  async _processVmlDrawingEntry(entry, model, name) {
    const xform = new VmlNotesXform();
    const vmlDrawing = await xform.parseStream(entry);
    model.vmlDrawings[`../drawings/${name}.vml`] = vmlDrawing;
  }

  async _processThemeEntry(entry, model, name) {
    await new Promise((resolve, reject) => {
      // TODO: stream entry into buffer and store the xml in the model.themes[]
      const stream = new StreamBuf();
      entry.on("error", reject);
      stream.on("error", reject);
      stream.on("finish", () => {
        model.themes[name] = stream.read().toString();
        resolve();
      });
      entry.pipe(stream);
    });
  }

  /**
   * @deprecated since version 4.0. You should use `#read` instead. Please follow upgrade instruction: https://github.com/exceljs/exceljs/blob/master/UPGRADE-4.0.md
   */
  createInputStream() {
    throw new Error(
      "`XLSX#createInputStream` is deprecated. You should use `XLSX#read` instead. This method will be removed in version 5.0. Please follow upgrade instruction: https://github.com/exceljs/exceljs/blob/master/UPGRADE-4.0.md"
    );
  }

  async read(stream, options) {
    // TODO: Remove once node v8 is deprecated
    // Detect and upgrade old streams
    if (!stream[Symbol.asyncIterator] && stream.pipe) {
      stream = stream.pipe(new PassThrough());
    }
    const chunks = [];
    for await (const chunk of stream) {
      chunks.push(chunk);
    }
    return this.load(Buffer.concat(chunks), options);
  }

  async load(data, options) {
    let buffer;
    if (options && options.base64) {
      buffer = Buffer.from(data.toString(), "base64");
    } else {
      buffer = data;
    }

    const model = {
      worksheets: [],
      worksheetHash: {},
      worksheetRels: [],
      themes: {},
      media: [],
      mediaIndex: {},
      drawings: {},
      drawingRels: {},
      comments: {},
      tables: {},
      vmlDrawings: {},
    };

    const zip = await JSZip.loadAsync(buffer);
    for (const entry of Object.values(zip.files)) {
      /* eslint-disable no-await-in-loop */
      if (!entry.dir) {
        let entryName = entry.name;
        if (entryName[0] === "/") {
          entryName = entryName.substr(1);
        }
        let stream;
        if (
          entryName.match(/xl\/media\//) ||
          // themes are not parsed as stream
          entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/)
        ) {
          stream = new PassThrough();
          stream.write(await entry.async("nodebuffer"));
        } else {
          // use object mode to avoid buffer-string convention
          stream = new PassThrough({
            writableObjectMode: true,
            readableObjectMode: true,
          });
          let content;
          // https://www.npmjs.com/package/process
          if (process.browser) {
            // running in browser, use TextDecoder if possible
            content = bufferToString(await entry.async("nodebuffer"));
          } else {
            // running in node.js
            content = await entry.async("string");
          }
          const chunkSize = 16 * 1024;
          for (let i = 0; i < content.length; i += chunkSize) {
            stream.write(content.substring(i, i + chunkSize));
          }
        }
        stream.end();
        switch (entryName) {
          case "_rels/.rels":
            model.globalRels = await this.parseRels(stream);
            break;

          case "xl/workbook.xml": {
            const workbook = await this.parseWorkbook(stream);
            model.sheets = workbook.sheets;
            model.definedNames = workbook.definedNames;
            model.views = workbook.views;
            model.properties = workbook.properties;
            model.calcProperties = workbook.calcProperties;
            break;
          }

          case "xl/_rels/workbook.xml.rels":
            model.workbookRels = await this.parseRels(stream);
            break;

          case "xl/sharedStrings.xml":
            model.sharedStrings = new SharedStringsXform();
            await model.sharedStrings.parseStream(stream);
            break;

          case "xl/styles.xml":
            model.styles = new StylesXform();
            await model.styles.parseStream(stream);
            break;

          case "docProps/app.xml": {
            const appXform = new AppXform();
            const appProperties = await appXform.parseStream(stream);
            model.company = appProperties.company;
            model.manager = appProperties.manager;
            break;
          }

          case "docProps/core.xml": {
            const coreXform = new CoreXform();
            const coreProperties = await coreXform.parseStream(stream);
            Object.assign(model, coreProperties);
            break;
          }

          default: {
            let match = entryName.match(/xl\/worksheets\/sheet(\d+)[.]xml/);
            if (match) {
              await this._processWorksheetEntry(
                stream,
                model,
                match[1],
                options,
                entryName
              );
              break;
            }
            match = entryName.match(
              /xl\/worksheets\/_rels\/sheet(\d+)[.]xml.rels/
            );
            if (match) {
              await this._processWorksheetRelsEntry(stream, model, match[1]);
              break;
            }
            match = entryName.match(/xl\/theme\/([a-zA-Z0-9]+)[.]xml/);
            if (match) {
              await this._processThemeEntry(stream, model, match[1]);
              break;
            }
            match = entryName.match(
              /xl\/media\/([a-zA-Z0-9]+[.][a-zA-Z0-9]{3,4})$/
            );
            if (match) {
              await this._processMediaEntry(stream, model, match[1]);
              break;
            }
            match = entryName.match(/xl\/drawings\/([a-zA-Z0-9]+)[.]xml/);
            if (match) {
              await this._processDrawingEntry(stream, model, match[1]);
              break;
            }
            match = entryName.match(/xl\/(comments\d+)[.]xml/);
            if (match) {
              await this._processCommentEntry(stream, model, match[1]);
              break;
            }
            match = entryName.match(/xl\/tables\/(table\d+)[.]xml/);
            if (match) {
              await this._processTableEntry(stream, model, match[1]);
              break;
            }
            match = entryName.match(
              /xl\/drawings\/_rels\/([a-zA-Z0-9]+)[.]xml[.]rels/
            );
            if (match) {
              await this._processDrawingRelsEntry(stream, model, match[1]);
              break;
            }
            match = entryName.match(/xl\/drawings\/(vmlDrawing\d+)[.]vml/);
            if (match) {
              await this._processVmlDrawingEntry(stream, model, match[1]);
              break;
            }
          }
        }
      }
    }

    this.reconcile(model, options);

    // apply model
    this.workbook.model = model;
    return this.workbook;
  }

  // =========================================================================
  // Write

  addMedia(zip, model) {
    model.media.map((medium) => {
      if (medium.type === "image") {
        const filename = `xl/media/${medium.name}.${medium.extension}`;
        if (medium.filename) {
          const data = fs.createReadStream(medium.filename);
          return zip.append(data, { name: filename });
        }
        if (medium.buffer) {
          return zip.append(medium.buffer, { name: filename });
        }
        if (medium.base64) {
          const dataimg64 = medium.base64;
          const content = dataimg64.substring(dataimg64.indexOf(",") + 1);
          return zip.append(content, { name: filename, base64: true });
        }
      }
      throw new Error("Unsupported media");
    });
  }

  addDrawings(zip, model) {
    const drawingXform = new DrawingXform();
    const relsXform = new RelationshipsXform();

    model.worksheets.forEach((worksheet) => {
      const { drawing } = worksheet;
      if (drawing) {
        drawingXform.prepare(drawing, {});
        let xml = drawingXform.toXml(drawing);
        zip.append(xml, { name: `xl/drawings/${drawing.name}.xml` });

        xml = relsXform.toXml(drawing.rels);
        zip.append(xml, { name: `xl/drawings/_rels/${drawing.name}.xml.rels` });
      }
    });
  }

  addContentTypes(zip, model) {
    const xform = new ContentTypesXform();
    const xml = xform.toXml(model);
    zip.append(xml, { name: "[Content_Types].xml" });
  }

  addApp(zip, model) {
    const xform = new AppXform();
    const xml = xform.toXml(model);
    zip.append(xml, { name: "docProps/app.xml" });
  }

  addCore(zip, model) {
    const coreXform = new CoreXform();
    zip.append(coreXform.toXml(model), { name: "docProps/core.xml" });
  }

  addThemes(zip, model) {
    const themes = model.themes || { theme1: theme1Xml };
    Object.keys(themes).forEach((name) => {
      const xml = themes[name];
      const path = `xl/theme/${name}.xml`;
      zip.append(xml, { name: path });
    });
  }

  addOfficeRels(zip) {
    const xform = new RelationshipsXform();
    const xml = xform.toXml([
      {
        Id: "rId1",
        Type: XLSX.RelType.OfficeDocument,
        Target: "xl/workbook.xml",
      },
      {
        Id: "rId2",
        Type: XLSX.RelType.CoreProperties,
        Target: "docProps/core.xml",
      },
      {
        Id: "rId3",
        Type: XLSX.RelType.ExtenderProperties,
        Target: "docProps/app.xml",
      },
    ]);
    zip.append(xml, { name: "_rels/.rels" });
  }

  addWorkbookRels(zip, model) {
    let count = 1;
    const relationships = [
      { Id: `rId${count++}`, Type: XLSX.RelType.Styles, Target: "styles.xml" },
      {
        Id: `rId${count++}`,
        Type: XLSX.RelType.Theme,
        Target: "theme/theme1.xml",
      },
    ];
    if (model.sharedStrings.count) {
      relationships.push({
        Id: `rId${count++}`,
        Type: XLSX.RelType.SharedStrings,
        Target: "sharedStrings.xml",
      });
    }
    model.worksheets.forEach((worksheet) => {
      worksheet.rId = `rId${count++}`;
      relationships.push({
        Id: worksheet.rId,
        Type: XLSX.RelType.Worksheet,
        Target: `worksheets/sheet${worksheet.id}.xml`,
      });
    });
    const xform = new RelationshipsXform();
    const xml = xform.toXml(relationships);
    zip.append(xml, { name: "xl/_rels/workbook.xml.rels" });
  }

  addSharedStrings(zip, model) {
    if (model.sharedStrings && model.sharedStrings.count) {
      zip.append(model.sharedStrings.xml, { name: "xl/sharedStrings.xml" });
    }
  }

  addStyles(zip, model) {
    const { xml } = model.styles;
    if (xml) {
      zip.append(xml, { name: "xl/styles.xml" });
    }
  }

  addTables(zip, model) {
    const tableXform = new TableXform();

    model.worksheets.forEach((worksheet) => {
      const { tables } = worksheet;
      tables.forEach((table) => {
        tableXform.prepare(table, {});
        const tableXml = tableXform.toXml(table);
        zip.append(tableXml, { name: `xl/tables/${table.target}` });
      });
    });
  }

  addWorkbook(zip, model) {
    const xform = new WorkbookXform();
    zip.append(xform.toXml(model), { name: "xl/workbook.xml" });
  }

  addWorksheets(zip, model) {
    // preparation phase
    const worksheetXform = new WorksheetXform();
    const relationshipsXform = new RelationshipsXform();
    const commentsXform = new CommentsXform();
    const vmlNotesXform = new VmlNotesXform();

    // write sheets
    model.worksheets.forEach((worksheet) => {
      let xmlStream = new XmlStream(true);
      worksheetXform.render(xmlStream, worksheet);
      zip.append(xmlStream.xml, {
        name: `xl/worksheets/sheet${worksheet.id}.xml`,
      });

      if (worksheet.rels && worksheet.rels.length) {
        xmlStream = new XmlStream();
        relationshipsXform.render(xmlStream, worksheet.rels);
        zip.append(xmlStream.xml, {
          name: `xl/worksheets/_rels/sheet${worksheet.id}.xml.rels`,
        });
      }

      if (worksheet.comments.length > 0) {
        xmlStream = new XmlStream();
        commentsXform.render(xmlStream, worksheet);
        zip.append(xmlStream.xml, { name: `xl/comments${worksheet.id}.xml` });

        xmlStream = new XmlStream();
        vmlNotesXform.render(xmlStream, worksheet);
        zip.append(xmlStream.xml, {
          name: `xl/drawings/vmlDrawing${worksheet.id}.vml`,
        });
      }
    });
  }

  _finalize(zip) {
    return new Promise((resolve, reject) => {
      zip.on("finish", () => {
        resolve(this);
      });
      zip.on("error", reject);
      zip.finalize();
    });
  }

  prepareModel(model, options) {
    // ensure following properties have sane values
    model.creator = model.creator || "ExcelJS";
    model.lastModifiedBy = model.lastModifiedBy || "ExcelJS";
    model.created = model.created || new Date();
    model.modified = model.modified || new Date();

    model.useSharedStrings =
      options.useSharedStrings !== undefined ? options.useSharedStrings : true;
    model.useStyles =
      options.useStyles !== undefined ? options.useStyles : true;

    // Manage the shared strings
    model.sharedStrings = new SharedStringsXform();

    // add a style manager to handle cell formats, fonts, etc.
    model.styles = model.useStyles
      ? new StylesXform(true)
      : new StylesXform.Mock();

    // prepare all of the things before the render
    const workbookXform = new WorkbookXform();
    const worksheetXform = new WorksheetXform();

    workbookXform.prepare(model);

    const worksheetOptions = {
      sharedStrings: model.sharedStrings,
      styles: model.styles,
      date1904: model.properties.date1904,
      drawingsCount: 0,
      media: model.media,
    };
    worksheetOptions.drawings = model.drawings = [];
    worksheetOptions.commentRefs = model.commentRefs = [];
    let tableCount = 0;
    model.tables = [];
    model.worksheets.forEach((worksheet) => {
      // assign unique filenames to tables
      worksheet.tables.forEach((table) => {
        tableCount++;
        table.target = `table${tableCount}.xml`;
        table.id = tableCount;
        model.tables.push(table);
      });

      worksheetXform.prepare(worksheet, worksheetOptions);
    });

    // TODO: workbook drawing list
  }

  async write(stream, options) {
    options = options || {};
    const { model } = this.workbook;
    const zip = new ZipStream.ZipWriter(options.zip);
    zip.pipe(stream);

    this.prepareModel(model, options);

    // render
    this.addContentTypes(zip, model);
    this.addOfficeRels(zip, model);
    this.addWorkbookRels(zip, model);
    this.addWorksheets(zip, model);
    this.addSharedStrings(zip, model); // always after worksheets
    this.addDrawings(zip, model);
    this.addTables(zip, model);
    this.addThemes(zip, model);
    this.addStyles(zip, model);
    this.addMedia(zip, model);
    this.addApp(zip, model);
    this.addCore(zip, model);
    this.addWorkbook(zip, model);
    return this._finalize(zip);
  }

  writeFile(filename, options) {
    const stream = fs.createWriteStream(filename);

    return new Promise((resolve, reject) => {
      stream.on("finish", () => {
        resolve();
      });
      stream.on("error", (error) => {
        reject(error);
      });

      this.write(stream, options).then(() => {
        stream.end();
      });
    });
  }

  async writeBuffer(options) {
    const stream = new StreamBuf();
    await this.write(stream, options);
    return stream.read();
  }

  toTemplate(templateOptions) {
    const { model } = this.workbook;
    const zip = new ZipAdapter();

    this.prepareModel(model, {});

    // render
    this.addContentTypes(zip, model);
    this.addOfficeRels(zip, model);
    this.addWorkbookRels(zip, model);
    this.addWorksheetTemplates(zip, model);
    this.addDrawings(zip, model);
    this.addTables(zip, model);
    this.addThemes(zip, model);
    this.addMedia(zip, model);
    this.addApp(zip, model);
    this.addCore(zip, model);
    this.addWorkbook(zip, model);

    // 转换成template对象
    const workbookTemplate = new WorkbookTemplate(zip);
    this.workbook.worksheets.forEach((sheet, index) => {
      const sheetOption = _.assign({
        name: sheet.name,
        id: sheet.id,
        state: sheet.state
      }, templateOptions[index]);
      const sheetModel = model.worksheets.find(item => item.id === sheet.id);
      const sheetTemplate = workbookTemplate.addWorksheet(sheetOption);
      sheetTemplate.columns = sheet.columns;

      sheet._rows.forEach((row, rowIndex) => {
        const rowTemplate = sheetTemplate.getRow(rowIndex + 1);
        rowTemplate.copy(row);
        row._cells.forEach((cell, cellIndex) => {
          const cellTemplate = CellTemplate.createCell(Enums.getValueType(cell.value), rowTemplate, sheetTemplate.getCol(cellIndex + 1));
          cellTemplate.style = cell.style;
          rowTemplate.cells.push(cellTemplate);
        });
      });

      if (sheetModel) {
        // <mergeCells count=...><mergeCell ref=.../></>
        if (sheetModel.merges.length) {
          sheetTemplate.addMergeCells(sheetModel.merges);
        }
        // <drawing r:id=.../>
        if (sheetModel.drawing) {
          sheetTemplate.addDrawing(sheetModel.drawing.rId);
        }
      }
    });

    // 预渲染
    const styles = new StylesXform(true);
    workbookTemplate.preRender({
      styles
    });

    // 渲染styles.xml
    this.addStyles(zip, { styles });
    return workbookTemplate;
  }

  addWorksheetTemplates(zip, model) {

    const relationshipsXform = new RelationshipsXform();
    const commentsXform = new CommentsXform();
    const vmlNotesXform = new VmlNotesXform();

    // write sheets
    model.worksheets.forEach((worksheet) => {
      let xmlStream;
      if (worksheet.rels && worksheet.rels.length) {
        xmlStream = new XmlStream();
        relationshipsXform.render(xmlStream, worksheet.rels);
        zip.append(xmlStream.xml, {
          name: `xl/worksheets/_rels/sheet${worksheet.id}.xml.rels`,
        });
      }

      if (worksheet.comments.length > 0) {
        xmlStream = new XmlStream();
        commentsXform.render(xmlStream, worksheet);
        zip.append(xmlStream.xml, { name: `xl/comments${worksheet.id}.xml` });

        xmlStream = new XmlStream();
        vmlNotesXform.render(xmlStream, worksheet);
        zip.append(xmlStream.xml, {
          name: `xl/drawings/vmlDrawing${worksheet.id}.vml`,
        });
      }
    });
  }
}

XLSX.RelType = require("./rel-type");

module.exports = XLSX;
